Búsqueda semántica
El subcapítulo anterior dejó el vector ya persistido en module_embeddings_<tipo>. Este recorre la otra mitad del módulo: cómo se expone esa búsqueda al exterior y cómo está organizado el código para que añadir nuevos modos de consulta no obligue a tocar el existente.
El patrón Criteria
Criteria es una variante aplicada del Specification Pattern descrito por Evans y Fowler. La regla fundamental es que ese objeto vive en el dominio y que expresa qué se quiere buscar sin comprometerse con cómo, el motor de almacenamiento es el que lo resolverá.
Sin Criteria, la capa de aplicación se ensucia con todos los filtros posible que se le puede aplicar a cada entidad, y además los repositorios tienden a una explosión combinatoria de métodos específicos: findByEntityType, findByEntityTypeAndIds, findByEntityTypeAndIdsWithMinScore... Un único método matching(Criteria) — en este módulo, searchSimilar(criteria, vector) — reemplaza toda esa familia sin perder expresividad.
EmbeddingSearchCriteria
EmbeddingSearchCriteria vive en embeddings-domain/api/ y agrupa los filtros aplicables a una búsqueda semántica de chunks: el texto de consulta, el tipo de entidad sobre el que acotar, el topN que limita el número de coincidencias...
public record EmbeddingSearchCriteria(
String queryText,
EntityType entityType,
int topN,
List<Long> candidateIds, // Todavía no lo analizaremos
Double minScore) {
...
public static EmbeddingSearchCriteria forEntity(final String queryText,
final EntityType type, final int topN) {
return new EmbeddingSearchCriteria(queryText, type, topN, null, null);
}
public static EmbeddingSearchCriteria allEntities(final String queryText,
final int topN) {
return new EmbeddingSearchCriteria(queryText, null, topN, null, null);
}
...
}Lo que tiene las siguientes ventajas para nuestro propio caso:
| Ventaja del patrón | Manifestación concreta en dwall-module-embeddings |
|---|---|
| Un solo método de búsqueda en el repositorio | searchSimilar(criteria, vector) cubre filtrado por tipo, por candidatos y por score sin fragmentarse |
| Dominio agnóstico del motor | El service no conoce <=>, ni ::vector, ni el formato del literal de pgvector |
| Extensibilidad sin fricciones | Añadir un predicado nuevo (hasMaxAge, hasOwnerFilter) requiere una factory adicional y un if en el adapter, no nuevas clases en el puerto |
| Frontera dominio/infraestructura | El Criteria atraviesa controller → service → adapter como objeto opaco, sin que ninguna capa intermedia lo abra |
La explicación canónica del patrón está en la nota del curso DDD — Agregados, donde se desarrolla la versión genérica del Criteria, la convivencia con paginación cursor y la justificación de su ubicación en el Shared Bounded Context.
El service: orquestador delgado
Como se puede observar, absolutamente toda la lógica detras de la busqueda de los 8 recursos diferentes (y la busqueda general), se queda resumida en tres líneas por método y ningún if.
...
public List<EmbeddingSearchResult> search(final EmbeddingSearchCriteria
criteria) {
final float[] vector = embeddingGenerator.generate(criteria.queryText(),
EmbeddingTaskType.RETRIEVAL_QUERY);
return embeddingSearchRepository.searchSimilar(criteria, vector);
}
...Eso es lo que el patrón Criteria habilita: el service no necesita saber qué tipo de búsqueda está orquestando. Su trabajo se reduce a generar el vector de la query con RETRIEVAL_QUERY — el taskType que activa el modo "consulta" de Gemini, distinto del RETRIEVAL_DOCUMENT que se usó en la indexación — y delegar al repositorio. Sin reglas de negocio propias: viven en el Criteria (qué combinaciones son válidas) o en el adapter (cómo se traducen a SQL).
Del Criteria al SQL: el adapter
El adapter EmbeddingSearchRepositoryImpl vive en embeddings-persistence/repository/. Es donde el Criteria se materializa en consulta jOOQ:
@Override
public List<EmbeddingSearchResult> searchSimilar(
final EmbeddingSearchCriteria criteria,
final float[] queryVector
) {
final String vectorLiteral = VectorLiteralConverter.convert(queryVector);
final List<EmbeddingMatch> matches = criteria.hasEntityFilter()
? fetchFromEntityTable(criteria, vectorLiteral)
: fetchFromUnifiedView(criteria, vectorLiteral);
return matches.stream()
.filter(match -> match.entityType() != null)
.flatMap(match -> toSearchResult(match).stream())
.collect(Collectors.toList());
}La primera decisión la toma el adapter consultando criteria.hasEntityFilter(): si el Criteria está acotado, ataca la tabla específica de ese tipo (pgvector indexa más eficientemente sobre tablas individuales que sobre vistas); si no, ataca la vista module_embeddings_unified.
Cada predicado del Criteria se traduce a una condición jOOQ, y DSL.trueCondition() actúa como elemento neutro cuando no hay filtro. Añadir un predicado nuevo — hasMaxAge(), hasOwnerFilter(), lo que sea — solo requiere otro if aquí, sin tocar la cláusula SELECT ni la conversión de resultados. La consulta crece en filtros sin fragmentarse en variantes con explosión combinatoria de métodos.
Diagrama de secuencia
El Criteria atraviesa toda la cadena sin transformarse, lo que el controller construye es exactamente lo que el adapter interpreta, pasando por el service como un objeto opaco que ni siquiera se inspecciona. Esa transparencia entre capas es la prueba de que la frontera entre dominio e infraestructura está bien trazada.